Skip to main content

03 - Temporal 与确定性重放

前置01 篇 4.1 节的两种恢复模型。

本篇回答:为什么这类框架禁止在编排代码里用随机数和系统时间、这个约束的边界到底在哪、以及 Agent 代码该怎么组织才不会被它拖住发布节奏。

术语

  • Workflow(工作流):编排逻辑,必须确定性
  • Activity(活动):实际执行副作用的函数,允许任意不确定操作
  • Event History(事件历史):服务端记录的该工作流发生过的全部事件
  • Command(命令):工作流代码执行时向服务端发出的意图,例如"启动一个 Activity"

一、它存的是事件,不是状态

02 篇里 LangGraph 存状态快照。Temporal 存事件历史。

它存的是「发生过什么」,不是「现在是什么」Event History① 启动 Activity 搜索 → 返回 X② 启动 Activity 总结 → 返回 Y③ 启动 Activity 写库 → 无结果崩溃后:工作流函数从第一行重跑执行到 ① 处不真调用直接返回历史值 X执行到 ② 处不真调用直接返回历史值 Y执行到 ③ 处历史里没有结果真正执行「重跑」听起来吓人,但前两步并没有真的再调一次模型或数据库 —— 它们只是从历史里把返回值取出来,快得几乎不花时间。代价是:你的工作流代码必须保证重跑时走出完全相同的分支,否则历史就对不上了。这就是确定性约束的来源。
理解这张图,就理解了为什么 Temporal 要求「模型调用必须放进 Activity」:只有被包进 Activity 的操作才会被记进历史,没包的部分每次重放都会真的再执行一遍。

"从第一行重跑"是理解这套机制的关键,它是后面所有约束的来源。

服务端在重放时逐条比对:

When the Workflow's code replays, the Commands that are emitted are compared with the existing Event History. If a corresponding Event already exists within the Event History that matches that command, then the Execution progresses.

二、确定性约束

代码要重跑并走出完全相同的路径,任何导致路径分叉的操作都被禁止。

2.1 禁止清单

操作分叉原因正确做法
random()两次取值不同,可能进入不同分支移入 Activity,或用 SDK 的确定性随机
time.now()同上用 SDK 提供的工作流时钟
直接发 HTTP / 查数据库重放时会真的再次打到外部系统移入 Activity
直接调用大模型同 prompt 两次输出可能不同移入 Activity
起线程、依赖系统调度顺序执行顺序不可复现用 SDK 的并发原语

官方文档明确点名了 LLM 调用:

Workflow code must be deterministic to support replay. To handle non-deterministic operations like API calls, LLM/AI invocations, database queries, and other external interactions, put them in Activities.

2.2 代码组织的实际形态

# ❌ 错误:模型调用直接写在工作流函数里
# 重放时会真的再打一次模型,且返回值可能与历史不同 → 路径分叉
@workflow.defn
class ResearchWorkflow:
@workflow.run
async def run(self, topic: str) -> str:
resp = openai.chat.completions.create(...) # 不确定操作,禁止
if "需要深入" in resp.choices[0].message.content:
...

# ✅ 正确:不确定操作封进 Activity,工作流只做编排
@activity.defn
async def call_model(prompt: str) -> str:
"""Activity 内部允许任意不确定操作。
它的返回值会被写进 Event History,重放时直接取历史值,不会重复调用。"""
resp = openai.chat.completions.create(
model="gpt-4o", messages=[{"role": "user", "content": prompt}]
)
return resp.choices[0].message.content


@workflow.defn
class ResearchWorkflow:
@workflow.run
async def run(self, topic: str) -> str:
# execute_activity 会产生一个 Command,被记入 Event History。
# 重放时比对到同一条 Command,直接返回历史结果。
summary = await workflow.execute_activity(
call_model,
f"总结 {topic}",
start_to_close_timeout=timedelta(minutes=2),
# 重试策略由框架执行,不需要自己写 try/except 循环
retry_policy=RetryPolicy(maximum_attempts=3),
)
# 这里的分支判断基于 Activity 的返回值。
# 因为返回值来自历史,重放时必然走同一分支 —— 确定性得以保持。
if "需要深入" in summary:
...

2.3 非确定性错误的两个来源

① 内在非确定性② 代码变更代码里直接用了随机数 / 时间戳 / IO同样的输入,可能产生不同的 Command 序列性质:bug —— 把它包进 Activity,改代码就好部署新版本,线上仍有实例跑在旧历史上新代码重放旧历史,Command 序列对不上性质:流程问题 —— 要用版本化 API,改代码没用两者报出来的错长得一样,但处理方式完全不同。左边是你写错了;右边是你写对了,只是发布节奏和长任务的生命周期撞上了。所以跑长任务的服务,发布时要考虑「此刻还有多少实例跑在旧代码的历史上」—— 这是一个部署问题,不是代码问题。
右边这一类是长任务系统特有的:任务跑三天,中间你发了两次版,第三天恢复时用的是第三版代码去重放第一版留下的历史。

第一类是写错了,改掉即可。第二类才是生产上真正会咬人的 —— 它把发布节奏和工作流实例的生命周期绑在了一起。对一个跑三天的长任务来说,这意味着三天内不能随意改工作流代码。

三、约束的实际边界

"不能改代码"的说法过于笼统。实际受限的只有会产生 Command 的操作。官方列出的是三类:

Starting or cancelling a Timer, Scheduling or cancelling Activity Executions, Starting or cancelling Child Workflow executions

定时器、Activity、子工作流

3.1 可以自由修改的

@workflow.defn
class ResearchWorkflow:
@workflow.run
async def run(self, topic: str) -> str:
summary = await workflow.execute_activity(call_model, ...)

# 下面这些都不产生 Command,重放不受影响,可以随时改:
title = summary.strip().split("\n")[0] # 纯计算
title = f"【调研】{title}" # 改字符串拼接方式
workflow.logger.info("已生成标题: %s", title) # 加日志
result = self._format(summary, title) # 重构辅助函数
return result

3.2 不能修改的

# 以下四类改动会破坏与旧历史的兼容:
# 1) 调换两个 Activity 的先后顺序
# 2) 新增一个 Activity
# 3) 删除一个 Activity
# 4) 增删定时器或子工作流
# 需要改时,用 SDK 的版本化 API 让新旧历史各走各的分支。

这条推论把"不能改代码"收窄成了"不能改这三类操作的序列",可操作性完全不同。

3.3 对 Agent 场景的实践建议

Agent 里改动最频繁的恰恰是 prompt 和工具编排。缓解方式:

做法效果
prompt、工具调用、模型调用全部放进 Activity本来就是必须的;顺带让这些高频改动不触碰 Command 序列
工作流函数只保留最稳定的流程骨架工作流改得越少,版本兼容负担越轻
prompt 作为参数传入 Activity,而非硬编码改 prompt 变成改配置,完全不动代码

四、它换来了什么

02 篇 5.1 节列出的 LangGraph 缺口,在这里都有对应答案:

能力机制
自动恢复服务端知道工作流未结束,会调度另一个 worker 接手,无需自写巡检
挂起不占资源等待三天期间没有进程运行,定时器由服务端维护
自动重试Activity 失败按策略重试,次数、退避、超时均为配置项
跨服务编排工作流可调用其他服务的 Activity,不限单进程
执行记录完整Event History 本身就是精确到每次调用的执行记录

4.1 最后一条对 Agent 的额外价值

Agent 可观测性里需要自行埋点才能看清的执行过程,在这里是重放机制的副产品 —— 它本来就必须记录每次 Activity 调用。

但重放会污染 trace:同一个工作流可能被重放多次,若在工作流函数里直接埋点,同一步会出现 N 次 span。处理方式见 05 篇

五、接入成本

成本项说明
运行一个集群Temporal Server 是独立的分布式系统,需要自己的存储(Cassandra / MySQL / PostgreSQL)。有托管版可免运维,需付费
代码结构调整Agent 代码要拆成 Workflow + Activity 两层,不是加装饰器就能完成
团队认知成本最贵的一项。不理解重放机制的人会写出隐蔽的非确定性缺陷,且只在崩溃恢复时暴露 —— 恰是最不希望出问题的时刻

5.1 选型判断

不适合:团队无分布式工作流经验,且诉求仅为"Agent 挂了能续上"。这种情况下 Temporal 属于过度工程,应先看 04 篇

适合:跨多个服务、运行数天、涉及资金或不可撤销操作的业务流程(订单履约、审批流、资金结算)。这类场景下约束换来的可靠性是划算的,这也是它在金融与电商领域被广泛采用的原因。

下一篇04 - DBOS 与 Restate

← 回到 专题索引  ·  Agent Infra 板块总览